import base64
import json
import re
import requests
import os

os.system('chcp 65001')  # 将cmd的显示字符编码从默认的GBK改为UTF-8


class QiHang:
    def __init__(self):
        self.headers = {
            'accept': 'application/json, text/plain, */*',
            'accept-language': 'zh-CN,zh;q=0.9',
            'cache-control': 'no-cache',
            'content-type': 'application/json;charset=UTF-8',
            'origin': 'https://www.iqihang.com',
            'referer': 'https://www.iqihang.com/ark/exam26?code=11',
            'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_3 like Mac OS X) AppleWebKit/605.2.11 (KHTML, like Gecko) Version/15.3 Mobile/15P492T Safari/602.10 Edg/149.0.0.0',
        }

    def send_data(self, method, url, json_data=None, params=None, stream=None, headers=None, allow_redirects=False):
        if headers is None:
            headers = self.headers
        resp = requests.request(method=method, url=url, json=json_data, params=params, headers=headers, stream=stream, allow_redirects=allow_redirects)
        return resp

    def login(self, account, password):
        json_data = {
            'clientType': 2,
            'address': 'https://www.iqihang.com/ark/exam26',
            'browser': 'chrome',
            'browserVersion': '131.0.0.0',
            'loginAddress': '',
            'loginProduct': '启航教育',
            'loginTerminal': 4,
            'loginType': 2,
            'loginVersion': '1.0.0',
            'network': '',
            'networkType': '',
            'os': 'Win32',
            'osVersion': 'window 10',
            'plant': '',
            'plantType': '',
            'source': '启航教育',
            'sourceType': 17,
            'terminal': 4,
            'version': '1.0.0',
            'account': f'{account}',
            'password': f'{password}',
        }
        response = self.send_data("POST", url='https://www.iqihang.com/api/ark/sso/login', json_data=json_data)
        dataJson = response.json()
        if dataJson['code'] == 0:
            token = dataJson["data"]["token"]
            print("\033[1;32m登录成功！！\033[0m")
            return f"Bearer {token}"
        else:
            msg = dataJson['msg']
            raise ValueError(f'{msg}')

    # =====获取账户下拥有的课程信息并选择需要下载哪个课程包  程序初始化入口=====
    def get_ProductInfoDic(self):
        ProductInfoDic_list = []
        index = 0
        params = {
            'status': '',
            'type': '1',
        }
        response = self.send_data("GET", url='https://www.iqihang.com/api/ark/web/v1/user/course/course-list', params=params)
        dataJson = response.json()
        for data in dataJson["data"]:
            userProductId = data["id"]
            productName = data["productName"]
            productCurriculumId = data["productCurriculumId"]
            ProductInfoDic = {
                "userProductId": userProductId,
                "productName": productName,
                "productCurriculumId": productCurriculumId
            }
            ProductInfoDic_list.append(ProductInfoDic)
            print(f"序号{index}：{ProductInfoDic}")
            index += 1
        return ProductInfoDic_list

    # =====获取产品包含的的视频详细信息，主要需要视频名称和vid=====
    def get_ProductJson(self, productCurriculumId):
        response = self.send_data("GET", url=f'https://www.iqihang.com/api/ark/web/v1/course/catalog/{productCurriculumId}')
        return response.json()

    # =====从文件中提取vid=====
    def get_vid(self, productCurriculumId):
        ProductJson = self.get_ProductJson(productCurriculumId)
        try:
            video_name_vid_list = list()
            dataJson = ProductJson["data"]["courseNodes"]
            resourceName_list = re.findall(r'\'resourceName\': (.*?),', str(dataJson), re.S)
            vid_list = re.findall(r'\'vid\': (.*?),', str(dataJson), re.S)
            if len(resourceName_list) == len(vid_list):
                for i in range(len(resourceName_list)):
                    name = resourceName_list[i].replace("\'", "").replace(".mp4", "")
                    vid = vid_list[i].replace("\'", "")
                    if vid_list[i] != 'None':
                        video_name_vid_list.append((name, vid))
                return video_name_vid_list
            else:
                raise "匹配到的名称和vid数量不一致, 请联系作者"
        except Exception as e:
            raise f"get_vid()报错：{e}"

    # =====获取视频链接======
    def get_video_url(self, vid, userProductId):
        # 非9位vid视频返回m3u8链接
        if len(vid) != 9:
            url = self.get_m3u8_url(vid)
        else:
            # 9位vid的视频直接返回.MP4链接
            url = self.get_mp4_url(vid, userProductId)
        return url

    # 非9位vid视频返回m3u8链接
    def get_m3u8_url(self, vid):
        headers = {
            'Host': 'p.bokecc.com',
            'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
            'referer': 'https://www.iqihang.com/',
            'accept-language': 'zh-CN,zh;q=0.9',
        }
        params = {
            'vid': f'{vid}',
            'siteid': 'A183AC83A2983CCC',  # 固定写死
        }
        response = self.send_data("GET", url='https://p.bokecc.com/servlet/getvideofile', params=params, headers=headers)
        dataJson = json.loads(response.text.replace('null(', '')[:-1])
        try:
            url = dataJson["copies"][1]["playurl"]
            return url
        except:
            return None

    # 9位vid的视频返回MP4链接
    def get_mp4_url(self, vid, userProductId):
        while True:
            params = {
                'id': '1',  # 应为resourceId，但实际不影响，但不可为空不可删除
                'userProductId': userProductId,  # 用户产品id
                'vid': vid,
                'isAllowAudition': '0',  # 可选，可删除
            }
            response = self.send_data("GET", url='https://www.iqihang.com/api/ark/web/v1/baijiayun/player/token', params=params, allow_redirects=False)
            token = response.json()["data"]["token"]
            params = {
                "vid": vid,
                "token": token
            }
            response = self.send_data("GET", url='https://www.baijiayun.com/vod/video/getPlayUrl', params=params)
            dataJson = json.loads(response.text)
            enc_url_list = []
            for i in ['1080p', 'superHD']:
                for j in range(0, 3):
                    enc_url_list.append(dataJson["data"]["play_info"][i]["cdn_list"][j]["enc_url"])
            for enc_url in enc_url_list:
                url = self.decodeUrl(enc_url)
                if url != -1:
                    return url

    # =====下载视频=====
    def Download(self, name, url, workDir, M3U8_path):
        if url is not None:
            if ".m3u8?" in url:
                self.M3U8download(name, url, workDir, M3U8_path)
            else:
                self.download_mp4(name, url, workDir)

    # M3U8 使用第三方下载器下载，不再编写处理m3u8文件的代码
    def M3U8download(self, name, url, workDir, M3U8_path):
        # 构造目标文件路径
        file_path = os.path.join(workDir, f"{name}.mp4")

        # 检查文件是否存在
        if os.path.exists(file_path):
            print(f"文件已存在: {name}，跳过下载。")
            return
        # --headers
        headers_str = ""
        for key, value in self.headers.items():
            headers_str += f"{key}:{value}|"
        headers_str = headers_str.strip("|")
        # 下载命令
        cmd = f'{M3U8_path} "{url}" --workDir "{workDir}" --saveName "{name}" --maxThreads "32" --retryCount "3" --headers "{headers_str}" --enableDelAfterDone '
        os.system(cmd)

    # mp4 使用requests下载
    def download_mp4(self, file_name, url, workDir):
        # 确保文件名以 .mp4 结尾
        if not file_name.endswith(".mp4"):
            file_name += ".mp4"

        if len(file_name.split(" ")) > 1:
            workDir = workDir + "/" + file_name.split(" ")[0]
        else:
            workDir = workDir

        # 构建完整文件路径
        full_path = os.path.join(workDir, file_name)

        # 创建下载路径目录（如果不存在）
        os.makedirs(workDir, exist_ok=True)

        # 检查文件是否已经存在并完整
        existing_size = os.path.getsize(full_path) if os.path.exists(full_path) else 0

        # 获取远程文件总大小
        try:
            head_response = requests.head(url)
            head_response.raise_for_status()
            total_size = int(head_response.headers.get('content-length', 0))
        except requests.exceptions.RequestException as e:
            print(f"无法获取文件大小：{e}")
            return

        if existing_size >= total_size:
            print(f"文件已存在且完整，跳过下载：{full_path}")
            return

        # 设置 HTTP 头部，指定下载的起始字节位置
        headers = {"Range": f"bytes={existing_size}-"} if existing_size > 0 else {}
        headers.update(self.headers)
        try:
            # 发送 HTTP GET 请求下载文件
            with self.send_data("GET", url=url, headers=headers, stream=True) as response:
                # 检查请求状态码
                if response.status_code not in (200, 206):
                    raise requests.exceptions.RequestException(f"Unexpected status code: {response.status_code}")

                # 将文件追加写入本地
                with open(full_path, "ab") as file:
                    for chunk in response.iter_content(chunk_size=8192):  # 每次读取 8KB
                        if chunk:  # 确保 chunk 非空
                            file.write(chunk)
                            existing_size += len(chunk)
                            # 显示进度条
                            print(f"\r正在下载{file_name}: {existing_size}/{total_size} bytes ({(existing_size / total_size) * 100:.2f}%)", end="")

            print(f"\n下载完成：{full_path}")
        except requests.exceptions.RequestException as e:
            print(f"下载失败：{e}  (重新运行如果还有这个报错联系我)")

    @staticmethod
    def decodeUrl(enc_url):
        try:
            encoded_str = enc_url.replace("bjcloudvod://", '')
            # 修复填充
            if len(encoded_str) % 4 != 0:
                encoded_str += "=" * (4 - len(encoded_str) % 4)
            # 解码为字节
            decoded_bytes = base64.b64decode(encoded_str)
            # 如果需要将字节转换为字符串
            decoded_str = decoded_bytes.decode("utf-8")
            n = ord(decoded_str[0]) % 8
            decoded_str = decoded_str[1:]
            s = []  # 初始化空列表
            l = 0  # 索引从 0 开始
            # 循环遍历字符串 t
            for l, a in enumerate(decoded_str):
                p = l % 4 * n + l % 3 + 1
                s.append(chr(ord(a) - p))  # 计算新的字符并添加到列表
            # 将处理后的字符列表合并成字符串
            dec_url = ''.join(s)
            return dec_url
        except:
            return -1

    # =====保存视频信息（视频名称、视频链接、视频vid、课程包名称、用户产品id、productCurriculumId） (链接有时效)=====
    def save_courseNodes_dic(self, productName, productCurriculumId, userProductId, courseNodes_dic):
        courseNodes_dic['productName'] = productName
        courseNodes_dic['userProductId'] = userProductId
        courseNodes_dic['productCurriculumId'] = productCurriculumId
        file = open(f'{productName}_courseInfo.json', mode='w', encoding='utf8')
        file.write(json.dumps(courseNodes_dic, ensure_ascii=False))
        file.close()

    def main_func(self, account, password, workDir=None, M3U8_Path=None):
        M3U8_path = M3U8_Path if M3U8_Path is not None else ".\\N_m3u8DL-CLI_v3.0.2.exe"
        workDir = workDir if workDir is not None else "./videos"

        # 登录
        authorization = self.login(account, password)
        self.headers.update({"authorization": authorization})

        # 获取账户下的课程包
        ProductInfoDic_list = self.get_ProductInfoDic()

        # 选择要下载的课程包
        index = int(input("\033[1;34m请选择序号：\033[0m"))
        print(f"\033[1;32m开始处理“{ProductInfoDic_list[index]['productName']}”\033[0m")
        ProductInfoDic = ProductInfoDic_list[index]
        productName = ProductInfoDic['productName']
        productCurriculumId = ProductInfoDic['productCurriculumId']
        userProductId = ProductInfoDic['userProductId']

        # 获取课程包中的所有视频ID
        video_name_vid_list = self.get_vid(productCurriculumId)

        # 记录视频信息（课程包名、用户产品ID、视频ID、视频名称、视频链接）
        courseNodes_dic = {'courseNodes': []}

        # 获取视频链接-在循环中下载而不是全部获取完成后一起下载，是因为视频链接有时效限制
        for name, vid in video_name_vid_list:
            url = self.get_video_url(vid, userProductId)  # 获取url

            # 记录视频信息
            courseNodes_dic["courseNodes"].append({
                "name": name,
                "vid": vid,
                "url": url
            })

            # 下载视频
            self.Download(name, url, workDir, M3U8_path)

        # 保存视频信息
        self.save_courseNodes_dic(productName, productCurriculumId, userProductId, courseNodes_dic)


if __name__ == '__main__':
    print("弃用！")
    try:
        print("作者：MySheep\n程序打包日期：2025年7月26日\n项目地址（仓库）：https://gitee.com/mysheep0/QiHangVideo_Download\n文章地址：https://blog.csdn.net/m0_60082046/article/details/144569851")
        print('\033[1;31m视频将存放在：“./videos”目录下\033[0m')
        account = str(input("\033[1;32m请输入账号（回车确认）：\033[0m")).replace("\'", "").replace("\"", "") or False
        password = str(input("\033[1;32m请输入密码（回车确认）：\033[0m")).replace("\'", "").replace("\"", "") or False
        if account is False or password is False:
            print("请输入账户和密码！")
        else:
            QiHang = QiHang()
            QiHang.main_func(account, password)
    except Exception as e:
        print(f"出现错误：{e}")
    input("按任意键退出...")